Skip to content

test(studio): pin three unpinned substrate/launch safety invariants (PX0 exit-3) - #507

Merged
KnockOutEZ merged 3 commits into
studio-handoff-corefrom
sd-175-test-studio-pin-three-unpinned-s
Aug 28, 2026
Merged

test(studio): pin three unpinned substrate/launch safety invariants (PX0 exit-3)#507
KnockOutEZ merged 3 commits into
studio-handoff-corefrom
sd-175-test-studio-pin-three-unpinned-s

Conversation

@KnockOutEZ

Copy link
Copy Markdown
Owner

Closes the PX0 exit-3 finding: three substrate/launch safety invariants that are correct in the code today but had nothing holding them there. Additive tests only — git diff --stat src/ is empty, and every arm passes against HEAD as-is.

Each arm below was proved to red under the stated mutation, and the mutation reverted by reverse-edit.


1. runStudio — the spawn error listener (tests/unit/cli/studio.test.ts)

spawn() reports EACCES, EPERM and a Gatekeeper refusal by emitting 'error' on a later tick, not by throwing, so runStudio's try/catch is not in that path. An 'error' with no listener is rethrown by EventEmitter as an uncaught exception — on a process that has already unref'd the child and returned to the human's shell. fakeSpawn returns { on: () => undefined }, so nothing observed the listener. Reuses auto-launch's real-EventEmitter fakeChild, which records listenerCount('error') at unref() time.

Mutation: delete child.on('error', …) from src/cli/studio.ts.

 × attaches the error listener BEFORE unref, so there is no window 4ms
 × reports the failure on the human line instead of rethrowing it as an uncaught exception 2ms
 Test Files  1 failed (1)
      Tests  2 failed | 82 passed (84)

AssertionError: expected [ 'unref:errorListeners=0' ] to deeply equal [ 'unref:errorListeners=1' ]
AssertionError: expected Error: spawn EACCES to be null

The other 82 arms — including all six existing runStudio cases — stayed green, which is the gap being closed. Restored: 84 passed (84).


2. isSingleDirectoryName. and .. (tests/unit/studio/substrate-acquire.test.ts)

The existing arm plants ../escape, so a predicate narrowed to !/[\\/]/ stays green — and that narrowing reads as a simplification. It is not. destDir is join(root, version) and the next statement is rmSync(destDir, { recursive: true, force: true }):

  • .destDir is the substrate root → deletes every installed version and record.json
  • ..destDir is the data dir → deletes the cache DB, keys and profiles

Neither spelling carries a separator.

Two things about the fixture shape, both deliberate and both departures from the issue's sketch:

  • It half-uninstalls first. With a valid record on disk acquireSubstrate returns already_present before it ever computes destDir, so the literal "install a good substrate, then attempt the bad version" never reaches the delete. The reachable state is record-present / executable-gone — an interrupted uninstall or partial upgrade — which this file already names elsewhere. The record is then read back at the end, once its executable is restored, which is only possible because neither it nor its directory was removed.
  • It asserts what survived, not the outcome word. Under the narrowed predicate .. still ends in failed: it wipes the data dir, then fails writing the record into the substrate/ directory it just deleted. An outcome-only arm would go green on the shape that wiped the machine.

Mutation: return Boolean(name) && !/[\\/]/.test(name);

 × refuses version "." rather than making it the directory it deletes 8ms
 × refuses version ".." rather than making it the directory it deletes 4ms
 Test Files  1 failed (1)
      Tests  2 failed | 36 passed (38)

"."   AssertionError: expected 'acquired' to be 'failed'
        ❯ substrate-acquire.test.ts:340  expect(r.outcome).toBe('failed')

".."  AssertionError: expected false to be true
        ❯ substrate-acquire.test.ts:343  expect(existsSync(installed)).toBe(true)

The pre-existing ../escape arm was among the 36 that stayed green. Restored: 38 passed (38).


3. findEscapingLink — termination on a contained cycle (tests/unit/studio/substrate-acquire.test.ts)

Termination rests on one fact: readdirSync(withFileTypes) stats without following, so a link to a directory reports isSymbolicLink() and is judged rather than descended. The framework fixture has contained directory links, but none points at an ancestor, so the walk finished for reasons unrelated to the rule.

self -> . is a legal, contained tree that must install. Descending into contained directory links — which reads as making the walk more thorough — turns it into self/self/self/…, inside acquireSubstrate on the warmup path, unattended and with no timeout of its own.

Mutation: after the containment check passes, if (statSync(child).isDirectory()) pending.push(child);

 × installs a tree whose directory link points at its own parent 14ms
 Test Files  1 failed (1)
      Tests  1 failed | 37 passed (38)

AssertionError: expected 'failed' to be 'acquired'
        ❯ substrate-acquire.test.ts:601  expect(r.outcome).toBe('acquired')

r.error: ELOOP: too many symbolic links encountered, stat
         '…/substrate/3.3.7/self/self/self/…/self'   (32 levels)

The runaway descent hits the platform's SYMLOOP_MAX and surfaces as a failed outcome rather than a hang, so the pin reports as a clean red. The per-test timeout is kept anyway, so a platform where it does hang still reports as a failing test rather than as a runner that stopped making progress.


Verification

npx tsc --noEmit          exit 0
npm run typecheck:studio  exit 0
npm run gate:studio       exit 0   (all 88 safety-importing tests in the gate; tests/ debt holds at baseline 363)

Territory: tests/unit/studio/** (lane) and tests/unit/cli/studio.test.ts (declared exception). No source, no internal-docs/, no CLAUDE.md, no Makefile. All probe fixtures under $TMPDIR.

…ded process kill

`spawn()` reports EACCES, EPERM and a Gatekeeper refusal by emitting 'error' on a
later tick, not by throwing, so runStudio's try/catch never sees it and an
unlistened event is rethrown as an uncaught exception on a CLI process that has
already detached the child and returned to the shell. The listener has always
been there; nothing asserted it, because fakeSpawn returns { on: () => undefined }.

Reuses auto-launch's real-EventEmitter fake so the ordering claim is about the one
moment the command stops being able to attach anything, not about what a later
look happens to find.
…its own root

The existing arm plants '../escape', so a predicate narrowed to !/[\\/]/ stays
green — and that narrowing reads as a simplification. It is not: destDir is
join(root, version) and the next statement is a recursive rmSync, so '.' deletes
every installed version plus the record and '..' deletes the data dir, cache DB,
keys and profiles. Neither carries a separator.

The fixture half-uninstalls first because a valid record short-circuits to
already_present before destDir is ever computed; record-present-executable-gone
is the reachable state, and is the one this file already names elsewhere.

Asserts what survived rather than the outcome word: under the narrowed predicate
'..' still ends in 'failed' — after wiping the data dir, then failing to write the
record into the substrate/ directory it just deleted.
…k cycle

Termination rests on one fact: readdirSync(withFileTypes) stats without following,
so a link to a directory reports isSymbolicLink() and is judged rather than
descended. The framework fixture has contained directory links but none pointing at
an ancestor, so the walk finished for reasons unrelated to the rule.

'self -> .' is a legal, contained tree that must install. Descending into contained
directory links — which reads as making the walk more thorough — turns it into
self/self/self/... The caller is acquireSubstrate on the warmup path, unattended
and with no timeout of its own.

Carries a per-test timeout so a lost rule reports as a failing test rather than a
runner that stopped making progress.
@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: dd4944be-f965-47d0-ae9b-4fd35ad463f7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@KnockOutEZ
KnockOutEZ merged commit f714540 into studio-handoff-core Aug 28, 2026
20 checks passed
@KnockOutEZ
KnockOutEZ deleted the sd-175-test-studio-pin-three-unpinned-s branch August 28, 2026 00:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant